view.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. 'use client';
  2. import './style.scss';
  3. import Link from 'next/link';
  4. import Image from 'next/image';
  5. import { redirect, useRouter } from 'next/navigation';
  6. import { useState, useEffect, useCallback, MouseEvent } from 'react';
  7. import { Menu } from 'lucide-react';
  8. import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
  9. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  10. import { faBookmark as nBookmark, faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
  11. import { faQrcode, faPrint, faLink, faShareNodes, faBookmark as yBookmark, faThumbsUp as yThumbsUp, faThumbsDown as yThumbsDown, faFlag as yFlag } from '@fortawesome/free-solid-svg-icons';
  12. import Loading from '@/app/component/Loading';
  13. import Comment from '@/app/(forum)/comment/view';
  14. import LatestList from '@/app/(forum)/post/_component/LatestPosts';
  15. import BoardResponse from '@/dtos/response/forum/board/boardResponse';
  16. import PostResponse from '@/dtos/response/forum/post/postResponse';
  17. import PostReactionRequest from '@/dtos/request/forum/post/postReactionRequest';
  18. import PostBookmarkRequest from '@/dtos/request/forum/post/postReactionRequest';
  19. import Content from '../_component/Content';
  20. import QRCode from '../_component/QRCode';
  21. import Copied from '../_component/Copied';
  22. import SnsShare from '../_component/SnsShare';
  23. import Report from '../_component/Report';
  24. import { Reaction } from '@/constants/forum';
  25. import { fetchPostReaction, fetchPostBookmark, fetchPostDelete } from '@/lib/api/forum/post';
  26. import { getDateTime, throwError, formatDate, isDateOverdue } from '@/lib/utils/client';
  27. import useAuth from '@/hooks/useAuth';
  28. type Props = {
  29. _board: BoardResponse,
  30. _post: PostResponse
  31. };
  32. export default function View({ _board, _post }: Props)
  33. {
  34. useEffect(() => {
  35. // 신고 횟수 초과 게시글은 접근 불가
  36. if (_post.reports > _board.boardMeta.view.blameHideCount && _board.boardMeta.view.blameHideCount > 0) {
  37. alert('비공개 게시글입니다.');
  38. redirect(`/board/${_post.boardCode}${window.location.search}`);
  39. }
  40. }, []);
  41. const router = useRouter();
  42. const { member, isLogined } = useAuth();
  43. const [error, setError] = useState<string>('');
  44. const [loading, setLoading] = useState<boolean>(false);
  45. const [qrCode, setQrCode] = useState<boolean>(false);
  46. const [copied, setCopied] = useState<boolean>(false);
  47. const [snsShare, setSnsShare] = useState<boolean>(false);
  48. const [report, setReport] = useState<boolean>(false);
  49. const [hasLike, setHasLike] = useState<boolean>(_post.hasLike);
  50. const [hasDisLike, setHasDisLike] = useState<boolean>(_post.hasDislike);
  51. const [hasBookmark, setHasBookmark] = useState<boolean>(_post.hasBookmark);
  52. const [hasReport, setHasReport] = useState<boolean>(_post.hasReport);
  53. useEffect(() => {
  54. if (error) {
  55. alert(error);
  56. setError('');
  57. }
  58. }, [error]);
  59. const toggleQRCode = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
  60. e.preventDefault();
  61. setQrCode((prev) => !prev);
  62. }, []);
  63. const handlePrint = useCallback(() => {
  64. window.print();
  65. }, []);
  66. const toggleCopied = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
  67. e.preventDefault();
  68. setCopied((prev) => !prev);
  69. }, []);
  70. const toggleSnsShare = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
  71. e.preventDefault();
  72. setSnsShare((prev) => !prev);
  73. }, []);
  74. // 좋아요/싫어요
  75. const handleReaction = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
  76. const reaction = Number(e.currentTarget.value);
  77. if (!await isLogined()) {
  78. return;
  79. }
  80. fetchPostReaction({ postID: _post.id, reaction: reaction } as PostReactionRequest).then(res => {
  81. if (res.ok) {
  82. switch (reaction) {
  83. case Reaction.Like:
  84. setHasLike(!hasLike);
  85. setHasDisLike(false);
  86. break;
  87. case Reaction.Dislike:
  88. setHasDisLike(!hasDisLike);
  89. setHasLike(false);
  90. break;
  91. }
  92. } else {
  93. throwError(res);
  94. }
  95. }).catch((err) => {
  96. setError(err.message);
  97. }).finally(() => {
  98. setLoading(false);
  99. });
  100. }, [member, hasLike, hasDisLike]);
  101. // 즐겨찾기
  102. const handleBookmark = useCallback(async () => {
  103. if (!await isLogined()) {
  104. return;
  105. }
  106. fetchPostBookmark({ postID: _post.id } as PostBookmarkRequest).then(res => {
  107. if (res.ok) {
  108. setHasBookmark(!hasBookmark);
  109. } else {
  110. throwError(res);
  111. }
  112. }).catch((err) => {
  113. setError(err.message);
  114. }).finally(() => {
  115. setLoading(false);
  116. });
  117. }, [member, hasBookmark]);
  118. // 신고하기 시작
  119. const handleReport = useCallback(async () => {
  120. if (hasReport) {
  121. alert('이미 신고하셨습니다.');
  122. return;
  123. }
  124. if (!await isLogined()) {
  125. return;
  126. }
  127. setReport((prev) => !prev);
  128. }, [member, hasReport]);
  129. // 수정하기
  130. const handleEdit = useCallback(async () => {
  131. if (!await isLogined()) {
  132. return;
  133. }
  134. // 게시글 삭제 보호 확인
  135. if (_board.boardMeta.general.allowUpdateProtection && !member?.isAdmin) {
  136. if (isDateOverdue(_post.createdAt, _board.boardMeta.general.updateProtectionDays)) {
  137. return alert(`게시글 작성 후 ${_board.boardMeta.general.updateProtectionDays}일이 지나 수정이 불가능합니다.`);
  138. }
  139. }
  140. router.push(`/post/edit/${_post.id}`);
  141. }, [member]);
  142. // 게시글 삭제
  143. const handleDelete = useCallback(async () => {
  144. if (!await isLogined()) {
  145. return;
  146. }
  147. // 게시글 삭제 보호 확인
  148. if (_board.boardMeta.general.allowDeleteProtection && !member?.isAdmin) {
  149. if (isDateOverdue(_post.createdAt, _board.boardMeta.general.deleteProtectionDays)) {
  150. return alert(`게시글 작성 후 ${_board.boardMeta.general.deleteProtectionDays}일이 지나 삭제가 불가능합니다.`);
  151. }
  152. }
  153. if (confirm('정말 삭제하시겠습니까?')) {
  154. fetchPostDelete(_post.id).then(res => {
  155. if (res.ok) {
  156. alert('게시글이 삭제되었습니다.');
  157. router.push(`/board/${_post.boardCode}`);
  158. } else {
  159. throwError(res);
  160. }
  161. }).catch((err) => {
  162. setError(err.message);
  163. }).finally(() => {
  164. setLoading(false);
  165. });
  166. }
  167. }, [member]);
  168. return (
  169. <div id='postView'>
  170. {loading && <Loading />}
  171. <QRCode isEnable={true} open={qrCode} onChange={setQrCode} />
  172. <Copied isEnable={true} open={copied} onChange={setCopied} />
  173. <SnsShare isEnable={true} open={snsShare} onChange={setSnsShare} />
  174. <Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} memberID={member?.id} />
  175. {/* 글 제목 */}
  176. <section className='subject whitespace-normal break-words'>
  177. {_post.boardPrefixID && ("[" + _post.boardPrefix.name + "]")} {_post.subject}
  178. </section>
  179. <hr />
  180. {/* 글 작성자/작성일시/부가기능들 */}
  181. <section className='attribution'>
  182. {_board.boardMeta.view.showMemberPhoto && (
  183. <div>
  184. <article className='writer-thumb'>
  185. <Image src='/resources/thumb.gif' alt='회원 사진' width={84} height={0} />
  186. </article>
  187. </div>
  188. )}
  189. <div>
  190. <article className='writer-info'>
  191. <ul>
  192. <li>※ {_post.writer.name}</li>
  193. {_board.boardMeta.view.showMemberRegDate && <li>{formatDate(_post.writer.createdAt)} 가입</li>}
  194. {_board.boardMeta.view.showMemberSummary && <li>{_post.writer.summary}</li>}
  195. </ul>
  196. </article>
  197. <article className='post-info'>
  198. <ul>
  199. <li>조회: {_post.views}</li>
  200. <li>댓글: {_post.comments}</li>
  201. {_board.boardMeta.view.allowLike && (
  202. <li>좋아요: {_post.likes}</li>
  203. )}
  204. {_board.boardMeta.view.allowDislike && (
  205. <li>싫어요: {_post.dislikes}</li>
  206. )}
  207. <li>IP: {_post.ipAddress}</li>
  208. </ul>
  209. </article>
  210. <article className='post-date'>
  211. 작성일시 : {getDateTime(_post.createdAt)}
  212. </article>
  213. <article className='functions'>
  214. <ul>
  215. {_board.boardMeta.view.allowPostUrlQrCode && (
  216. <li>
  217. <a href='#' rel='noreferrer' onClick={toggleQRCode}><FontAwesomeIcon icon={faQrcode} /> QR</a>
  218. </li>
  219. )}
  220. {_board.boardMeta.view.allowPrint && (
  221. <li>
  222. <a href='#' rel='noreferrer' onClick={handlePrint}><FontAwesomeIcon icon={faPrint} /> 인쇄</a>
  223. </li>
  224. )}
  225. {_board.boardMeta.view.allowPostUrlCopy && (
  226. <li>
  227. <a href='#' rel='noreferrer' onClick={toggleCopied}><FontAwesomeIcon icon={faLink} /> 주소</a>
  228. </li>
  229. )}
  230. {_board.boardMeta.view.allowSnsShare && (
  231. <li>
  232. <a href='#' rel='noreferrer' onClick={toggleSnsShare}><FontAwesomeIcon icon={faShareNodes} /> 공유</a>
  233. </li>
  234. )}
  235. </ul>
  236. </article>
  237. </div>
  238. </section>
  239. <hr />
  240. {/* 글 내용 */}
  241. <section className='content'>
  242. <Content boardMeta={_board.boardMeta} content={_post.content}></Content>
  243. {_post.tagList.length > 0 && (
  244. <article>
  245. {/* 태그 표시 */}
  246. {_post.tagList.map((row, i) => (
  247. <span key={i}>
  248. <Link href={`/tag/${row.slug}`}>#{row.slug}</Link>
  249. </span>
  250. ))}
  251. </article>
  252. )}
  253. </section>
  254. <hr />
  255. {/* 제어 버튼들 */}
  256. <section className='controls'>
  257. <article>
  258. <Link href={`/board/${_post.boardCode}${window.location.search}`} className='btn btn-default'>목록</Link>
  259. {_board.boardMeta.view.allowPrevNextBotton && (
  260. <>
  261. {!!_post.prevID && (
  262. <Link href={`/post/${_post.prevID}`} className='btn btn-default'>이전</Link>
  263. )}
  264. {!!_post.nextID && (
  265. <Link href={`/post/${_post.nextID}`} className='btn btn-default'>다음</Link>
  266. )}
  267. </>
  268. )}
  269. </article>
  270. <article className='functions'>
  271. {_board.boardMeta.view.allowLike && (
  272. <div className='hidden sm:block'>
  273. <button className='btn btn-default' title='좋아요' value={Reaction.Like} onClick={handleReaction}>
  274. <FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } />
  275. </button>
  276. </div>
  277. )}
  278. {_board.boardMeta.view.allowDislike && (
  279. <div className='hidden sm:block'>
  280. <button className='btn btn-default' title='싫어요' value={Reaction.Dislike} onClick={handleReaction}>
  281. <FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } />
  282. </button>
  283. </div>
  284. )}
  285. {_board.boardMeta.view.allowBookmark && (
  286. <div className='hidden md:block'>
  287. <button className='btn btn-default' onClick={handleBookmark}>
  288. <FontAwesomeIcon icon={!hasBookmark ? nBookmark : yBookmark } /> 즐겨찾기
  289. </button>
  290. </div>
  291. )}
  292. {_board.boardMeta.view.allowBlame && (
  293. <div className='hidden xlm:block'>
  294. <button className='btn btn-default' onClick={handleReport}>
  295. <FontAwesomeIcon icon={!hasReport ? nFlag : yFlag } /> 신고
  296. </button>
  297. </div>
  298. )}
  299. <div className='hidden xl:block'>
  300. <button className='btn btn-default' onClick={handleEdit}>수정</button>
  301. </div>
  302. <div className='hidden xl:block'>
  303. <button className='btn btn-default' onClick={handleDelete}>삭제</button>
  304. </div>
  305. <div className='block xl:hidden'>
  306. <DropdownMenu>
  307. <DropdownMenuTrigger asChild>
  308. <button className='btn btn-default' title='더보기'>
  309. <Menu className='w-5 h-5' />
  310. </button>
  311. </DropdownMenuTrigger>
  312. <DropdownMenuContent align='end'>
  313. {_board.boardMeta.view.allowLike && (
  314. <DropdownMenuItem className='block sm:hidden'>
  315. <button type="button" value={Reaction.Like} onClick={handleReaction}><FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } /> 좋아요</button>
  316. </DropdownMenuItem>
  317. )}
  318. {_board.boardMeta.view.allowDislike && (
  319. <DropdownMenuItem className='block sm:hidden'>
  320. <button type="button" value={Reaction.Dislike} onClick={handleReaction}><FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } /> 좋아요</button>
  321. </DropdownMenuItem>
  322. )}
  323. {_board.boardMeta.view.allowBookmark && (
  324. <DropdownMenuItem className='block md:hidden' onClick={handleBookmark}><FontAwesomeIcon icon={!hasBookmark ? nBookmark : yBookmark } /> 즐겨찾기</DropdownMenuItem>
  325. )}
  326. {_board.boardMeta.view.allowBlame && (
  327. <DropdownMenuItem className='block xlm:hidden' onClick={handleReport}><FontAwesomeIcon icon={!hasReport ? nFlag : yFlag } /> 신고</DropdownMenuItem>
  328. )}
  329. <DropdownMenuItem className='block xl:hidden' onClick={handleEdit}><FontAwesomeIcon icon={faPenToSquare} /> 수정</DropdownMenuItem>
  330. <DropdownMenuItem className='block xl:hidden' onClick={handleDelete}><FontAwesomeIcon icon={faTrashCan} /> 삭제</DropdownMenuItem>
  331. </DropdownMenuContent>
  332. </DropdownMenu>
  333. </div>
  334. </article>
  335. </section>
  336. <br/>
  337. {/* 댓글 */}
  338. <Comment board={_board} post={_post} />
  339. {/* 게시판 최근 글 */}
  340. <LatestList boardListMeta={_board.boardMeta.list} boardID={_board.id} boardCode={_board.code} postID={_post.id} />
  341. </div>
  342. );
  343. }